Skip to content

Auth review follow-ups: Bearer revocation parity, MCP reconnect auth, /auth/me exp discriminator (bd-rk55baiz) - #451

Merged
shikokuchuo merged 10 commits into
mainfrom
feature/auth-review-followups
Aug 3, 2026
Merged

Auth review follow-ups: Bearer revocation parity, MCP reconnect auth, /auth/me exp discriminator (bd-rk55baiz)#451
shikokuchuo merged 10 commits into
mainfrom
feature/auth-review-followups

Conversation

@shikokuchuo

@shikokuchuo shikokuchuo commented Aug 3, 2026

Copy link
Copy Markdown
Member

Three fixes from the 2026-08-03 auth review. Full plan and verification record: claude-notes/plans/2026-08-03-bearer-revocation-and-mcp-auth-followups.md.

1. Bans and "log out everywhere" now apply to MCP/CLI clients too (bd-jkih1ql7).
Before: banning a user or logging out everywhere only killed browser sessions — an MCP client (or a stolen Google ID token) kept working, for up to an hour or indefinitely. Now the hub checks its revocation list on every Bearer-token request: banned users are refused outright, and tokens issued before a "log out everywhere" are rejected. Logging in again immediately afterwards still works, exactly as in the browser.

2. MCP clients now explain auth failures instead of silently retrying forever (bd-l3b1brn8).
Before: if the hub rejected an MCP client mid-session (revoked credentials, ban), the client just looked "offline" and retried forever — indistinguishable from a network outage. Now it recognizes a definite rejection: it refreshes the token and tries once more, and if that doesn't help, the next tool call fails immediately with a clear message — either "please re-authenticate" or "your account is not allowed on this hub". Genuine network problems behave as before: keep retrying, never log out.

3. /auth/me now says what kind of credential its expiry refers to (bd-aw8f3sp8).
Before: the exp field meant a sliding session expiry for browser cookies but a fixed token expiry for Bearer tokens, and nothing told callers which they got. Now the response includes credential: "session" or "bearer", and the web client no longer falls back to a guessed 1-hour lifetime when no expiry is reported.

All three were developed test-first, pass the full cargo xtask verify, and were exercised end-to-end against the real hub binary and the real q2 mcp client (ban, log-out-everywhere, and mid-session revocation scenarios).

Bans and logout-everywhere not_before floors previously only gated
session cookies — a banned user kept full MCP access indefinitely and
a stolen Google ID token survived logout-everywhere for its remaining
lifetime (~1h).

authenticate_claims_for_kind gains a RevocationEnforcement{Enforce,Skip}
parameter; the ledger check (ban -> 403 user_banned, iat < not_before
-> 401 bearer_revoked, missing iat fails closed at anchor 0) runs
between the allowlist check and the auth_ok emission, so a denied
request never logs allow-then-deny. The Bearer dispatch arm enforces;
mint callers (auth_callback/auth_session via authenticate_claims) skip
— bans gate mint explicitly and the min_auth_time clamp keeps
same-second re-login working.

Tests: 6 new integration tests in auth_bearer.rs (5 observed failing
pre-fix); support.rs gains TestHubBuilder::not_before_subs and
ClaimsBuilder::no_iat. E2E: scripts/hub-bearer-revocation-e2e.mjs
drives the real hub binary through the stopped-hub ban/floor procedure
(12/12 checks); full-stack MCP e2e-auth.test.ts green against the
patched hub. Docs: MCP README residual-window paragraph,
session-auth-operations.md, sub_denylist notes in the loopback-PKCE
plan annotated.
…try (bd-l3b1brn8)

A 401/403 on the WS upgrade — or a terminal refresh failure — was
indistinguishable from a network blip: ws folds non-101 upgrades into a
generic error, NodeWebSocketClientAdapter swallowed getBearer failures,
and the retry loop spun silently forever. A revoked grant mid-session
presented as an immortal, silent 'offline'.

sync-client (evidence): the WebSocket factory seam gains an optional
onUpgradeStatus capability. The default ws factory attaches the
EventEmitter-only 'unexpected-response' event and aborts the handshake
itself (ws skips abortHandshake when a listener exists — a no-op
handler leaks one connection per attempt, verified against a raw net
server). The adapter reports definitive evidence only via
onAuthRejected — upgrade 401/403, or a ReauthRequired-named getBearer
error (name-based: the cross-package contract; TokenRefreshError stays
transient) — debounced to one report per failure episode, reset at
peer handshake. Terminal refresh failures stop the retry loop. Network
errors never report and never stop retrying. Also fixed: disconnect()
during a CONNECTING socket crashed the process via ws's unhandled
'closed before established' error event.

hub-mcp (policy): ConnectionManager.handleAuthRejected coalesces
concurrent reports into one forceRefresh+reprobe cycle. Recovery is
silent; persistent 401 invalidates the grant, drops dead project
handles, and gates the next tool call to fail fast with ReauthRequired
(self-heals when fresh credentials appear); 403 — evidence, recheck,
or initial probe — surfaces HubAccessDeniedError ('banned or not in
the allowlist') with the keyring deliberately kept.

Tests: 7 new sync-client specs (incl. real-ws leak pin), 10 new
connection-manager specs, 2 new e2e-auth cases (grant revocation and
mid-session ban via F1's ledger, real hub binary + real keyring).
Suites: sync-client 137, hub-mcp 246, hub-client build:all + 130
tests, q2 embed rebuilt and verified via --launcher-info.
…3sp8)

AuthMeResponse.exp meant 'sliding session expiry' on the cookie path
but 'fixed Google token expiry' on the Bearer path, with nothing in
the response distinguishing them — a latent trap for any Bearer
caller that schedules from it.

Hub: AuthMeResponse gains credential: "session" | "bearer"
(mirroring the AuthenticatedUser variants); exp is documented as the
expiry of the presented credential. Additive — exp itself unchanged
on both paths.

SPA: AuthState carries the discriminator, and the dead pre-sliding
DEFAULT_SESSION_MS = 1 h fallback in useAuth is removed — when exp is
absent no expiry re-check is scheduled (the mount, visibility-change,
keep-alive, and disconnected-probe checks all remain). The fallback
was unreachable against a sliding hub and would have probed ~168x too
often if it ever fired.

Tests: both hub /auth/me integration tests extended with credential
assertions (observed failing first); authService mapping test;
useAuth fallback-pinned tests rewritten to schedule from an explicit
server exp plus a new no-exp -> no-schedule spec (a simulated week,
zero re-checks). Full cargo xtask verify green. E2E against the real
hub binary: Bearer /auth/me -> credential=bearer with the token's own
exp; session cookie -> credential=session with the sliding ~7d exp.
@posit-snyk-bot

posit-snyk-bot commented Aug 3, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@shikokuchuo
shikokuchuo merged commit c6ab84c into main Aug 3, 2026
8 checks passed
@shikokuchuo
shikokuchuo deleted the feature/auth-review-followups branch August 3, 2026 09:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants